Skip to content

o/c/c/ntp: integrate NTP configuration options into snapd - #17022

Open
lorenzo-medici wants to merge 27 commits into
canonical:masterfrom
lorenzo-medici:integrate-ntp-configuration
Open

o/c/c/ntp: integrate NTP configuration options into snapd#17022
lorenzo-medici wants to merge 27 commits into
canonical:masterfrom
lorenzo-medici:integrate-ntp-configuration

Conversation

@lorenzo-medici

Copy link
Copy Markdown
Contributor

This is still a draft. For example, tests are missing.

This PR adds a set of configuration options to the core snap to allow control over the NTP utilities:

  • core.system.ntp.servers
  • core.system.ntp.fallback-servers
  • core.system.ntp.root-distance-max-sec
  • core.system.ntp.poll-interval-min-sec
  • core.system.ntp.poll-interval-max-sec
  • core.system.ntp.connection-retry-sec
  • core.system.ntp.save-interval-sec

These map to the systemd-timesyncd.service configuration keys in /etc/systemd/timesyncd.conf keys like so:

var snapToTimesyncdKeyMapping = map[string]string{
	"servers":               "NTP",
	"fallback-servers":      "FallbackNTP",
	"root-distance-max-sec": "RootDistanceMaxSec",
	"poll-interval-min-sec": "PollIntervalMinSec",
	"poll-interval-max-sec": "PollIntervalMaxSec",
	"connection-retry-sec":  "ConnectionRetrySec",
	"save-interval-sec":     "SaveIntervalSec",
}

The configuration is stored on disk. Whenever it is retrieved, it is read from the file, parsed and transformed into a JSON-like object.

When it is set, the entries are validated and the approved configuration is stored on disk. The service is then reloaded.

The keys are validated for format and values. The two servers keys must be a list of strings, whose items must be valid IP addresses or network names.
The other keys must be valid systemd timespans or positive integers (defaulting to seconds). Additionally, poll-interval-min-sec must be greater than 16s and lower than poll-interval-max-sec, and connection-retry-sec must be greater than 1s.

This has been tested manually in a VM and the operations validated by checking the contents of /etc/systemd/timesyncd.conf and the output of timedatectl show-timesync. Unit tests are still pending.

References:

@github-actions github-actions Bot added Needs Documentation -auto- Label automatically added which indicates the change needs documentation Run only one system Only runs spread tests on one system labels May 5, 2026
@github-actions

github-actions Bot commented May 5, 2026

Copy link
Copy Markdown

Thu Jul 2 14:32:12 UTC 2026
The following results are from: https://github.com/canonical/snapd/actions/runs/28596102772

No spread failures reported

@lorenzo-medici lorenzo-medici changed the title [DRAFT] Integrate NTP configuration options into snapd o/c/c/ntp: integrate NTP configuration options into snapd May 8, 2026
@lorenzo-medici
lorenzo-medici marked this pull request as ready for review May 8, 2026 14:45
@github-actions github-actions Bot removed the Run only one system Only runs spread tests on one system label May 8, 2026
@lorenzo-medici

lorenzo-medici commented May 8, 2026

Copy link
Copy Markdown
Contributor Author

I changed the PR name after pushing so the static checks failed because of it and nothing else was run after that.
Could someone re-trigger the tests?

Edit: the new test run didn't pick up the updated PR name. I force-pushed with an identical commit with different date to trigger the complete workflow.

@lorenzo-medici
lorenzo-medici force-pushed the integrate-ntp-configuration branch from 3f858d7 to ef6b868 Compare May 12, 2026 08:01
@codecov

codecov Bot commented May 12, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.77352% with 15 lines in your changes missing coverage. Please review.
✅ Project coverage is 79.15%. Comparing base (57190d4) to head (49db17e).
⚠️ Report is 81 commits behind head on master.

Files with missing lines Patch % Lines
overlord/configstate/configcore/ntp.go 94.71% 11 Missing and 4 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master   #17022      +/-   ##
==========================================
- Coverage   79.17%   79.15%   -0.03%     
==========================================
  Files        1377     1384       +7     
  Lines      193225   194801    +1576     
  Branches     2466     2466              
==========================================
+ Hits       152989   154186    +1197     
- Misses      31056    31364     +308     
- Partials     9180     9251      +71     
Flag Coverage Δ
unittests 79.15% <94.77%> (-0.03%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@ndyer
ndyer requested review from valentindavid and removed request for Mohit-Chachada May 19, 2026 12:42
@Meulengracht
Meulengracht requested a review from Copilot June 1, 2026 12:45

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces core.system.ntp.* configuration options for Ubuntu Core systems and wires them into configcore so snapd can read/write systemd-timesyncd settings (via /etc/systemd/timesyncd.conf) and apply changes.

Changes:

  • Add a new configcore NTP module that validates NTP options, serializes them to a systemd-style config, and exposes the current system configuration via an external config getter.
  • Add unit tests covering valid/invalid configurations and error paths.
  • Register the new NTP handler in configcore filesystem-only handlers.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 18 comments.

File Description
overlord/configstate/configcore/ntp.go Implements NTP option validation, (de)serialization, external config getter, and application logic (write config + reload/restart timesyncd).
overlord/configstate/configcore/ntp_test.go Adds gocheck tests for validation, file content updates, and error handling.
overlord/configstate/configcore/handlers.go Registers the new system.ntp filesystem-only handler.

Comment thread overlord/configstate/configcore/ntp.go Outdated
Comment thread overlord/configstate/configcore/ntp.go Outdated
Comment thread overlord/configstate/configcore/ntp.go
Comment thread overlord/configstate/configcore/ntp.go
Comment thread overlord/configstate/configcore/ntp_test.go Outdated
Comment thread overlord/configstate/configcore/ntp_test.go Outdated
Comment thread overlord/configstate/configcore/ntp_test.go Outdated
Comment thread overlord/configstate/configcore/ntp_test.go
Comment thread overlord/configstate/configcore/ntp.go Outdated
Comment thread overlord/configstate/configcore/handlers.go Outdated
@Meulengracht
Meulengracht requested a review from pedronis June 3, 2026 08:54
@Meulengracht Meulengracht added the Needs Samuele review Needs a review from Samuele before it can land label Jun 3, 2026

@pedronis pedronis left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we need to be consistent with other duration options

Comment thread overlord/configstate/configcore/ntp.go Outdated
return fmt.Errorf("%v: %v", key, err)
}

timespanUs, err := validateSystemdTimeSpanFormat(span)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we use golang durations in other system options, we need to be consistent with that here, and also find namings that don't have "-sec" in the option names

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These values end up being written to /etc/systemd/timesyncd.conf, and should be in systemd.time format.

Regarding the option names, they are a snap-friendly formatting of the timesyncd config names. I have renamed the options to slightly clearer values:

var timesyncdToSnapKeyMapping = map[string]string{
	"NTP":                "servers",
	"FallbackNTP":        "fallback-servers",
	"RootDistanceMaxSec": "max-root-time-distance",
	"PollIntervalMinSec": "min-poll-interval",
	"PollIntervalMaxSec": "max-poll-interval",
	"ConnectionRetrySec": "connection-retry-interval",
	"SaveIntervalSec":    "save-interval",
}

Let me know if some need to be changed. I will push with the next set of commits.

[1] man timesyncd.conf
[2] man systemd.time

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

they can't be because we don't use that format anywhere else, you need convert from go duration to what you need there

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh I understand now, you mean for the input values that the user set's and get's.
I will add the translation steps.

@lorenzo-medici
lorenzo-medici force-pushed the integrate-ntp-configuration branch 2 times, most recently from ba3b38a to c01a6af Compare June 9, 2026 07:54
@lorenzo-medici

lorenzo-medici commented Jun 9, 2026

Copy link
Copy Markdown
Contributor Author

A dependency I needed was removed from go.mod in master, making the CI fail to build. I rebased the branch and added the module back, so I needed to force-push.

Edit: and again

Signed-off-by: Lorenzo Medici <lorenzo.medici@canonical.com>
…l, etc.

Signed-off-by: Lorenzo Medici <lorenzo.medici@canonical.com>
…ng improvements, bugfixes

Signed-off-by: Lorenzo Medici <lorenzo.medici@canonical.com>
…onfiguration

Signed-off-by: Lorenzo Medici <lorenzo.medici@canonical.com>
Signed-off-by: Lorenzo Medici <lorenzo.medici@canonical.com>
…keys in config file, expand test coverage

Signed-off-by: Lorenzo Medici <lorenzo.medici@canonical.com>
Signed-off-by: Lorenzo Medici <lorenzo.medici@canonical.com>
Signed-off-by: Lorenzo Medici <lorenzo.medici@canonical.com>
Signed-off-by: Lorenzo Medici <lorenzo.medici@canonical.com>
Signed-off-by: Lorenzo Medici <lorenzo.medici@canonical.com>
Signed-off-by: Lorenzo Medici <lorenzo.medici@canonical.com>
…r list parsing

Signed-off-by: Lorenzo Medici <lorenzo.medici@canonical.com>
…e operations

Signed-off-by: Lorenzo Medici <lorenzo.medici@canonical.com>
Signed-off-by: Lorenzo Medici <lorenzo.medici@canonical.com>
…clude all of them

Signed-off-by: Lorenzo Medici <lorenzo.medici@canonical.com>
Signed-off-by: Lorenzo Medici <lorenzo.medici@canonical.com>
…gs to go time.Duration strings

Signed-off-by: Lorenzo Medici <lorenzo.medici@canonical.com>
Signed-off-by: Lorenzo Medici <lorenzo.medici@canonical.com>
@lorenzo-medici
lorenzo-medici force-pushed the integrate-ntp-configuration branch from 72fad8b to 8d229f4 Compare June 12, 2026 14:33
@lorenzo-medici
lorenzo-medici requested a review from pedronis June 12, 2026 15:12
Comment thread overlord/configstate/configcore/ntp.go Outdated
}

// Too simplistic?
var validHostname = regexp.MustCompile(`^([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])(\.([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]{0,61}[a-zA-Z0-9]))*$`).MatchString

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is that something that will delay start of snapd? I think @bboozzoo was looking into improving start time, but not sure.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[a-zA-Z0-9\-] -> [a-zA-Z0-9-] should work.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it really have to be a host name? ipv6 address are not matched by it.

var validIPv4 = net.ParseIP

// Match the line containing "μs:" or "us:" and capture the following digits
var timespanUsRegexp = regexp.MustCompile(`(?:μs|us):\s*(\d+)`)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-ascii? Could we use "us" instead?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified on UC24:

# systemd-analyze timespan 1d
Original: 1d
      μs: 86400000000
   Human: 1d

Comment thread overlord/configstate/configcore/ntp.go Outdated
}

func validateServerName(serverAddress string) error {
if validIPv4(serverAddress) == nil && !validHostname(serverAddress) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what if my NTP server is accessible over IPv6?

Comment thread overlord/configstate/configcore/ntp.go Outdated

// Too simplistic?
var validHostname = regexp.MustCompile(`^([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])(\.([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]{0,61}[a-zA-Z0-9]))*$`).MatchString
var validIPv4 = net.ParseIP

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this actually parses ipv6 as well and there's no need to use a variable

Comment thread overlord/configstate/configcore/ntp.go Outdated
// Validation for user submitted values has already been done, so ParseDuration cannot fail here.
if minVal, exists := ntpCfg["min-poll-interval"]; exists {
pollIntervalMinString = minVal.(string)
pollIntervalMin, _ = time.ParseDuration(strings.TrimSpace(pollIntervalMinString))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
pollIntervalMin, _ = time.ParseDuration(strings.TrimSpace(pollIntervalMinString))
pollIntervalMin, _ = time.ParseDuration(pollIntervalMinString)

Comment thread overlord/configstate/configcore/ntp.go Outdated
}
if maxVal, exists := ntpCfg["max-poll-interval"]; exists {
pollIntervalMaxString = maxVal.(string)
pollIntervalMax, _ = time.ParseDuration(strings.TrimSpace(pollIntervalMaxString))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
pollIntervalMax, _ = time.ParseDuration(strings.TrimSpace(pollIntervalMaxString))
pollIntervalMax, _ = time.ParseDuration(pollIntervalMaxString)

Comment thread overlord/configstate/configcore/ntp.go Outdated

// The value that the user inputs should be parsed as a Go duration string for consistency
// with other configuration options
duration, err := time.ParseDuration(strings.TrimSpace(valueStr))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
duration, err := time.ParseDuration(strings.TrimSpace(valueStr))
duration, err := time.ParseDuration(valueStr)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

side note, it's probably worth checking that the input does not contain extra whitespace

return byteStream
}

func getNTPFromSystemHelper(key string) (result any, err error) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should key be ignored?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I followed the flow from the netplan config, which also ignores the key.


func (s *ntpSuite) TestNTPSetRestartDaemonError(c *C) {
r := systemd.MockSystemctl(func(cmd ...string) ([]byte, error) {
s.sysdLog = append(s.sysdLog, cmd)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not checked in the test?

func (s *ntpSuite) SetUpTest(c *C) {
s.configcoreSuite.SetUpTest(c)

c.Assert(os.MkdirAll(filepath.Join(dirs.GlobalRootDir, "etc/systemd"), 0755), IsNil)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is done again just below

Suggested change
c.Assert(os.MkdirAll(filepath.Join(dirs.GlobalRootDir, "etc/systemd"), 0755), IsNil)

for i, test := range getConfigurationTests {
// Apply the config
conf := configcore.PlainCoreConfig(test.newConfig)
err := configcore.FilesystemOnlyRun(core24Dev, conf)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

won't this call systemd-analyze?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It will. The existing configuration is read to identify changes and the need to restart the timesyncd daemon. In the reading logic in getNTPFromSystem, systemd-analyze is used to convert systemd timespans into go Durations.

For this reason, I also cannot add the "exit 1" snippet mocking systemd-analyze.
The error paths are already covered, but I can mock it if you think it best.

// We are on Core
restore := release.MockOnClassic(false)
s.AddCleanup(restore)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you add a snippet like:

	cmd = testutil.MockCommand(c, "systemd-analyze", "echo 'systemd-analyze not mocked'; exit 1")
	s.AddCleanup(cmd.Restore()

 * replaced `\-` with `-` in regex for server address validation
 * removed `validIPv4` variable and directly used `net.ParseIP` as it supports IPv6
 * added test with IPv6 server being validated.

Signed-off-by: Lorenzo Medici <lorenzo.medici@canonical.com>
…r trailing whitespace

Signed-off-by: Lorenzo Medici <lorenzo.medici@canonical.com>
Signed-off-by: Lorenzo Medici <lorenzo.medici@canonical.com>

@Meulengracht Meulengracht left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First pass, some comments also this should have a spread test.

Comment thread overlord/configstate/configcore/ntp.go Outdated
Comment thread overlord/configstate/configcore/ntp.go Outdated
Comment thread overlord/configstate/configcore/ntp.go Outdated
Comment thread overlord/configstate/configcore/ntp.go Outdated
Comment thread overlord/configstate/configcore/ntp.go Outdated
Comment thread overlord/configstate/configcore/ntp.go Outdated
Comment thread overlord/configstate/configcore/ntp.go Outdated
Comment thread overlord/configstate/configcore/ntp.go
Comment thread overlord/configstate/configcore/ntp.go
Signed-off-by: Lorenzo Medici <lorenzo.medici@canonical.com>
Signed-off-by: Lorenzo Medici <lorenzo.medici@canonical.com>
…Helper

Signed-off-by: Lorenzo Medici <lorenzo.medici@canonical.com>
…nap configs

Signed-off-by: Lorenzo Medici <lorenzo.medici@canonical.com>
Comment thread tests/core/ntp-config/task.yaml Outdated
[ "$ts_before" != "$(get_restart_ts)" ]

echo "snap get reports no configuration option again after the reset"
not snap get system system.ntp No newline at end of file

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

missing newline

Comment on lines +98 to +104
echo "Updating a single option preserves the other configured options"
ts_before="$(get_restart_ts)"
snap set system system.ntp.min-poll-interval=1m
MATCH '^PollIntervalMinSec=1m$' < "$NTP_CONF"
MATCH '^NTP=192.168.1.1 ntp.ubuntu.com$' < "$NTP_CONF"
MATCH '^SaveIntervalSec=20s$' < "$NTP_CONF"
[ "$ts_before" != "$(get_restart_ts)" ]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should atleast assert one additional non updated key, maybe FallbackNTP to ensure that we actually preserve the field correctly.

unitOptions = append(unitOptions, &unitOption)
}

byteStream, _ := io.ReadAll(unit.Serialize(unitOptions))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needs a comment on why we are ignoring the error here

return nil, nil
}

file, err := os.Open(filepath.Join(dirs.GlobalRootDir, "etc", "systemd", "timesyncd.conf"))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we decide to ignore drop-ins, but we need to document this behaviour, both here in the code but also as a part of the forward-facing docs - but since this is core the presence of drop ins can be relatively limited, is there a requirement on this behaviour from what prompted this feature? @lorenzo-medici

…sful set operation

Signed-off-by: Lorenzo Medici <lorenzo.medici@canonical.com>
…d not using drop-ins for timesyncd configuration

Signed-off-by: Lorenzo Medici <lorenzo.medici@canonical.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Needs Documentation -auto- Label automatically added which indicates the change needs documentation Needs Samuele review Needs a review from Samuele before it can land

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants